Skip to content

Handle unknown event types gracefully instead of crashing#680

Open
Alan4506 wants to merge 3 commits intosmithy-lang:developfrom
Alan4506:fix/unknown-event-type-handling
Open

Handle unknown event types gracefully instead of crashing#680
Alan4506 wants to merge 3 commits intosmithy-lang:developfrom
Alan4506:fix/unknown-event-type-handling

Conversation

@Alan4506
Copy link
Copy Markdown
Contributor

@Alan4506 Alan4506 commented Apr 7, 2026

Background

The Smithy spec (streaming.html#client-behavior) states:

"Adding new events to an event stream union is considered a backward compatible change; clients SHOULD NOT fail when an unknown event is received."

Currently, in smithy-aws-event-stream deserializers.py, EventDeserializer.read_struct() does a hard dictionary lookup (schema.members[member_name]) which raises KeyError when the service sends an event type not present in the generated model. So we have to add some logic to handle unknown event types.

Additionally, although smithy-python automatically creates unknown variants for streams, these variants only have a tag field unlike normal events that have a value field. When AWSEventReceiver.receive() calls getattr(result, "value") without a default, it raises AttributeError for unknown variants.

These were discovered during Q Business Chat streaming integration testing. The service sends intermediateGroupEvent and intermediateMessageEvent events not in any published SDK model, causing the async for event in output_stream loop to crash.

Reference implementations in other Smithy SDKs:

Both smithy-java and smithy-rs (Rust) already handle unknown event types gracefully:

  • smithy-java (TestEventStream.java): Generates a $Unknown(String memberName) record for each event stream union. The builder has an $unknownMember(String memberName) method that the deserializer calls when it encounters an unrecognized event type. The unknown variant preserves the event type name.

  • smithy-rs (EventStreamUnmarshallerGenerator.kt): The generated unmarshaller has an _unknown_variant match arm that returns Ok(UnmarshalledMessage::Event(Output::Unknown)) for client targets. Unlike Java, the Rust unknown variant does not preserve the event type name.

This PR follows the smithy-java approach of preserving the event type name in the unknown variant's tag field, so users can identify which unmodeled event was received (e.g., ChatOutputStreamUnknown(tag="intermediateGroupEvent")).

Description of Changes

  1. smithy-aws-event-stream (deserializers.py): When member_name is not in schema.members, construct a synthetic Schema with member_index=-1 and call the consumer, which triggers the generated case _: branch in UnionGenerator.java.
  2. smithy-python codegen (UnionGenerator.java): The case _: branch (previously just logging) now also constructs the per-union unknown variant with the event type name as tag.
  3. smithy-aws-event-stream (aio/__init__.py): Change getattr(result, "value") to getattr(result, "value", None) to prevent AttributeError on unknown variants.

Testing

Regenerated all existing clients locally and all their integration tests passed. Also, verified with Q Business Chat streaming — unknown events (intermediateGroupEvent, intermediateMessageEvent) are now handled gracefully instead of crashing. Users can identify unknown events via isinstance and access the event type name:

from aws_sdk_qbusiness.models import ChatOutputStreamUnknown

async for event in output_stream:
    if isinstance(event, ChatOutputStreamUnknown):
        print(f"Unknown event: {event.tag}")

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@Alan4506 Alan4506 requested a review from a team as a code owner April 7, 2026 16:01
@Alan4506 Alan4506 changed the title Fix EvenHandle unknown event types gracefully instead of crashing Handle unknown event types gracefully instead of crashing Apr 7, 2026
logger.debug(
"Unknown event type: %s", member_name
)
from smithy_core.shapes import ShapeID
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move the ShapeID import to the top with the other smithy_core.shapes imports? Doesn't seem like we need it inline here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add unit tests in test_deserializers.py for the unknown event handling path.

Also, the test fixture EventStreamDeserializer._consumer still raises SmithyError in the default case. Should that be updated to match the new behavior?

result = self._deserializer(deserializer)
logger.debug("Successfully deserialized event: %s", result)
if isinstance(getattr(result, "value"), Exception):
if isinstance(getattr(result, "value", None), Exception):
Copy link
Copy Markdown
Contributor

@alexgromero alexgromero Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if isinstance(getattr(result, "value", None), Exception):
value = getattr(result, "value", None)
if isinstance(value, Exception):

Small readability suggestion (non-blocking): since only some variants have value, maybe assign it first and check that instead so it's a bit clearer and avoids looking up value twice here and in the raise statement.

message = EventMessage(
headers={
":message-type": "event",
":event-type": "intermediateGroupEvent",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
":event-type": "intermediateGroupEvent",
":event-type": "unmodeledEvent",

deserializer = EventDeserializer(event=source, payload_codec=JSONCodec())
result = EventStreamDeserializer().deserialize(deserializer)
assert isinstance(result, EventStreamUnknown)
assert result.tag == "intermediateGroupEvent"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert result.tag == "intermediateGroupEvent"
assert result.tag == "unmodeledEvent"

EventStreamDeserializer,
EventStreamErrorEvent,
EventStreamOperationInputOutput,
EventStreamUnknown,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
EventStreamUnknown,
EventStreamUnknownEvent,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not add "Event" at the end because the generated code does not have "Event" suffix after "Unknown".

assert source is not None
deserializer = EventDeserializer(event=source, payload_codec=JSONCodec())
result = EventStreamDeserializer().deserialize(deserializer)
assert isinstance(result, EventStreamUnknown)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
assert isinstance(result, EventStreamUnknown)
assert isinstance(result, EventStreamUnknownEvent)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not add "Event" at the end because the generated code does not have "Event" suffix after "Unknown".



UNKNOWN_EVENT_CASE = (
EventStreamUnknown(tag="intermediateGroupEvent"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - this event type is specific to QBusiness, we should use something generic:

Suggested change
EventStreamUnknown(tag="intermediateGroupEvent"),
EventStreamUnknownEvent(tag="unmodeledEvent"),

| EventStreamBlobPayloadEvent
| EventStreamErrorEvent
| EventStreamUnknownEvent
| EventStreamUnknown
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| EventStreamUnknown
| EventStreamUnknownEvent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I modified this because the generated code does not have "Event" suffix after "Unknown".

${4C|}
case _:
logger.debug("Unexpected member schema: %s", schema)
self._set_result($5L(tag=schema.member_name or ""))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use tag=schema.member_name or "" here instead of something like tag=schema.expect_member_name()?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove this TODO

member_schema, headers
)
consumer(member_schema, message_deserializer)
member_schema = schema.members.get(member_name)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking we can consolidate this logic into a member_schema = self._resolve_member_schema(schema, member_name) method to make this a bit cleaner.

# member_name and a member_index of -1 so the
# generated default branch constructs the unknown
# variant with the correct tag.
logger.debug("Unknown event type: %s", member_name)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic seems odd, why can't we do something like:

    def _resolve_member_schema(self, schema: Schema, member_name: str) ->
Schema:
        if member_schema := schema.members.get(member_name):
            return member_schema

        logger.debug(
            "Received unmodeled event stream member %s for union %s",
            member_name,
            schema.id,
        )
        return Schema.member(
            id=schema.id.with_member(member_name),
            target=schema,
            index=-1,
        )

This keeps the real event payload/headers intact and lets the generated union fallback produce *Unknown(tag=...) in one place, instead of special-casing unknown events in the runtime with a fake {} document.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants